Skip to content

Return the error of a failed listen from usockets to Bun.serve and Bun.listen - #44651

Draft
robobun wants to merge 7 commits into
mainfrom
robobun/30522bd0/serve-listen-errno
Draft

robobun wants to merge 7 commits into
mainfrom
robobun/30522bd0/serve-listen-errno

Conversation

@robobun

@robobun robobun commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #30363

Problem

  • On macOS and Windows every failed Bun.serve() or http.Server#listen() throws Failed to start server. Is port N in use? with code: "EADDRINUSE", errno: 0. On Linux errno is 0 for EADDRINUSE and EACCES.
  • HttpContext::listen (packages/bun-uws/src/HttpContext.h:1286) drops the int *error of uSockets. on_listen_failed (src/runtime/server/mod.rs:2046) reads thread-local errno, on Linux only.
  • uSockets writes no code when socket(), setsockopt() or getaddrinfo() fails. Bun.listen then has no code.

Fix

  • uSockets writes the code of each failing call to *error, and a failed getaddrinfo() result to a new *dns_error. uWS returns the listen socket and both codes. The listen callback is gone.
  • Bun.serve and Bun.listen decode both. A failed lookup throws getaddrinfo ENOTFOUND <host>. Bun.listen sets Node's negative errno.
  • Self-reviewed: 28 concerns raised, 23 addressed, 5 partly (Notes).
  • Verified: test/js/bun/http/serve-listen.test.ts and five more files, with the other suites in Notes.

Background

Downsides

  • Code that expects EADDRINUSE for each failed listen now gets the real code. Bun.listen errors have a negative errno (was positive).
  • On Windows Bun.serve reports WSAEACCES as EADDRINUSE. A port in a reserved range still reads Is port N in use?.
  • None found for a listen that succeeds: two int out-params, no new allocation or system call.
Notes

What JS sees on Linux x64. main is the release build 1.4.3-canary.1+367d939d9. Its listen error code is the same as the base of this PR. The PR column is a debug ASAN build.

case main this PR Node v26.3.0
busy port, Bun.serve and http.Server EADDRINUSE, errno: 0 EADDRINUSE, -98 EADDRINUSE, -98
busy port, Bun.listen and net.Server errno: 98 -98 -98
127.0.0.1:80 as a user that may not bind it (the report) EACCES, errno: 0 EACCES, -13 EACCES, -13
192.0.2.1, Bun.serve EADDRNOTAVAIL, -99 same EADDRNOTAVAIL, -99
hostname for which getaddrinfo() answers EAI_NONAME Bun.serve: a stale errno (ENOENT ... listen). Bun.listen: no code getaddrinfo ENOTFOUND <host> getaddrinfo ENOTFOUND <host>
same, EAI_AGAIN as above getaddrinfo EAI_AGAIN <host> not run
fe80::1%nope0 Bun.serve: EINVAL. Bun.listen: no code EINVAL, -22 EINVAL, -22
127.0.0.1 or localhost at the descriptor limit Bun.serve: EMFILE. Bun.listen: no code EMFILE, -24 not run
setsockopt() fails with EPERM Bun.serve: EPERM. Bun.listen: no code EPERM, -1 not run

On main, Bun.serve gets the rows below the report right on Linux only, from thread-local errno. macOS and Windows read no errno there.

Refs #25765: hostname: "something.localhost" printed Is port 3000 in use? on macOS. It now throws getaddrinfo ENOTFOUND something.localhost. The server still does not start.

The error of a failed getaddrinfo()

  • A name that does not resolve goes to *dns_error, and c_ares::Error::init_eai decodes it as it does for Bun.connect.
  • At the descriptor limit glibc answers EAI_NONAME for its first lookup and EAI_SYSTEM after that. Both leave EMFILE in errno, and uSockets reports that errno in *error.
  • An IPv6 literal needs no lookup. It fails when its zone names no interface, and uSockets reports EINVAL, as Node does.

Windows. I have no Windows machine. CI is the only check.

  • *error holds a WSA or Win32 code. SocketGroup::listen_errno decodes it through the Win32 table. Bun.listen used the errno form, which reads ERROR_PATH_NOT_FOUND (3) as ESRCH.
  • The unix path wrote two failures only with SetLastError. They now go to *error: a path that is too long, and a missing directory.
  • Winsock has no privileged ports. It answers WSAEACCES when another socket holds the port in some modes: in build 123622 a second Bun.serve on one port with no hostname got EACCES. on_listen_failed reports EACCES as EADDRINUSE on Windows. Bun.listen keeps the code that Winsock gave.
  • Each failed lookup reads ENOTFOUND on Windows: init_eai takes libuv numbers there and getaddrinfo() returns WSA codes.

Tests. These fail without the fix on Linux:

  • serve-listen.test.ts: the eight busy port cases (http and https) and a privileged port throws EACCES, which is the command of the report.
  • serve-syscall-fault.test.ts: poll_start → Bun.serve throws the error of the failed registration (ASAN builds only).
  • serve-epoll-add-fail.test.ts: the Bun.listen errno, the two setsockopt() cases and the getaddrinfo() case. They use the LD_PRELOAD shim of that file.
  • tcp-server.test.ts: Bun.listen at the file descriptor limit throws EMFILE and Bun.listen reports why a unix path cannot be bound.
  • node-http.test.ts and node-net-server.test.ts: a listen() error carries the errno of the failed bind. Both pass in Node.

The other new cases pass before and after on Linux, because main reads errno there. They guard macOS and Windows, and the Linux results that must not change.

Suites run with the fix (Linux x64, debug ASAN build): the six files above, fetch.unix.test.ts, socket-syscall-fault.test.ts, socket-dns-error.test.ts, unix-socket-unlink.test.ts, unix-socket-long-path.test.ts, socket.test.ts, node-net.test.ts, serve.test.ts, and the node tests test-http-bind-twice, test-net-bind-twice, test-net-server-listen-path, test-net-server-try-ports, test-net-eaddrinuse, test-net-listen-error, test-cluster-eaddrinuse, test-cluster-shared-handle-bind-error, test-cluster-eaccess. bun run rust:check-all passes.

In my container the debug build runs some subprocess tests past their 5 s limit: the a dispatch that throws group of node-http.test.ts, several tests of node-net.test.ts and socket.test.ts, and the half-open test of node-net-server.test.ts. Their fixtures print the expected result when I run them by hand, and the half-open test times out the same way on a build of the previous head. One test of serve.test.ts (#7187) expects EACCES as root and fails with and without this change.

Self-review: the 5 concerns that are partly done

  • Lookup failures on Windows all read ENOTFOUND (above).
  • No Windows test makes socket() fail. The descriptor limit tests need ulimit.
  • Three neighbours are named here with no tracking issue: TLS context errors, the unix socket file, and a reserved port on Windows (below).

Not in this PR

  • The HTTP/3 arm of Bun.serve: a failed UDP listen throws an Error with no code.
  • dgram bind still reports a positive errno.
  • TLS context errors: a bad tls.ca throws Failed to create HTTP server with no code.
  • address and port on the Bun.serve error. fix(node): format HTTP listen errors #42742 adds them in node:http.
  • When a unix listen fails after bind(), the socket file stays on disk.
  • A port in a reserved range on Windows (Downsides).

Open PRs on the same code


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/net/node-net-server.test.ts, test/js/node/http/node-http.test.ts, test/js/bun/net/tcp-server.test.ts, test/js/bun/http/serve-syscall-fault.test.ts, test/js/bun/http/serve-listen.test.ts, test/js/bun/http/serve-epoll-add-fail.test.ts

A failed listen reached Bun.serve as a bare null socket. on_listen_failed
then read thread-local errno, on Linux and Android only, and every other
failure became EADDRINUSE with errno 0.

uSockets now writes the code of the failing call to *error on every path
of a failed listen: socket(), the socket options, bind(), listen(), the
poll registration and the unix address. HttpContext::listen forwards it,
the uWS listen handler carries it as a second argument, and
on_listen_failed decodes it on every platform with
SocketGroup::listen_errno, which Bun.listen now uses too.
@robobun

robobun commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:29 AM PT - Oct 7th, 2026

❌ @robobun, your commit 3354ba7 has 2 failures in Build #123771 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 44651

That installs a local version of the PR into your bun-44651 executable, so you can run:

bun-44651 --bun

On Windows, bind() can answer WSAEACCES to a socket with
SO_EXCLUSIVEADDRUSE when another socket holds the port. Bun.serve sets
that option by default, so a second server on one port with no hostname
got EACCES once the real code reached it.
Comment thread src/runtime/server/mod.rs Resolved
Comment thread src/uws_sys/App.rs Resolved
Comment thread src/uws_sys/SocketGroup.rs Resolved
Comment thread src/uws_sys/SocketGroup.rs Resolved
Name the arguments of uws_listen_handler in its type. Split
SocketGroup::listen_errno by platform so each arm shows what it decodes.
Keep the contract of *error in libusockets.h only.
The uWS listen functions return the listen socket and take the out-params
of us_socket_group_listen: *error, and a new *dns_error for the result of
a failed getaddrinfo(). The listen callback and its trampolines are gone.

Bun.serve and Bun.listen decode both codes the same way. A hostname that
does not resolve throws `getaddrinfo ENOTFOUND <host>`. Bun.listen sets
the negative libuv errno. Bun.serve reports WSAEACCES as EADDRINUSE in
its own error path, and uSockets keeps the code that Winsock gave.
Comment thread src/runtime/server/mod.rs Resolved
Comment thread src/uws_sys/libuwsockets.cpp Resolved
@robobun robobun changed the title Bun.serve: carry the errno of a failed listen through the uWS listen handler Return the error of a failed listen from usockets to Bun.serve and Bun.listen Oct 7, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

binding privileged port incorrectly reports EADDRINUSE instead of EACCES on macos

1 participant